Skip to content

fix(soccer-scoreboard): correct TEAMS.md codes, explain why a league is empty - #233

Merged
ChuckBuilds merged 3 commits into
mainfrom
fix/soccer-team-codes
Jul 31, 2026
Merged

fix(soccer-scoreboard): correct TEAMS.md codes, explain why a league is empty#233
ChuckBuilds merged 3 commits into
mainfrom
fix/soccer-team-codes

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Reported problem

A user set their Premier League favourite to MUN for Manchester United — straight out of TEAMS.md — and saw nothing on the display. Two separate problems, and the docs were the first one.

1. TEAMS.md had wrong codes

ESPN uses MAN for Manchester United and MNC for Manchester City. The file said MUN and MCI. It wasn't isolated — diffing every documented code against ESPN's live endpoints:

League Wrong codes Also
Premier League MCIMNC, MUNMAN 4 relegated clubs still listed, 6 current ones missing
La Liga RMRMA 5 stale, 7 missing
Bundesliga AUGFCA, BAYMUN, BVBDOR, MNZM05 5 stale, 9 missing
Serie A ROMROMA, COMCOMO
Ligue 1 8 wrongOLLYON, OMOLM, ASMMON, TFCTOU, …

Every table is now generated from ESPN's live team endpoints and verified against them. I cross-checked the roster against the standings endpoint too, since the pre-season team list looked surprising — both agree exactly.

Worse than a typo: MUN is a real code — it's Bayern Munich. Favourites match by abbreviation across every enabled league, so anyone following the old docs with the Bundesliga also on would have quietly followed the wrong club. TEAMS.md now documents the codes that mean different clubs in different leagues (MUN, BRE, MON, PAR, SCP, FCA, TOR).

2. It failed silently, and two different causes looked identical

Matching is an exact string comparison against ESPN's abbreviation with no aliasing (DynamicTeamResolver.resolve_teams is a pass-through for soccer), so a plausible code matches nothing and the display just stays empty.

The trap is that between seasons a correct code produces exactly the same empty screen — and the logs couldn't tell them apart. In this user's case both were true: MUN is wrong, and the 2026-27 Premier League doesn't start until 21 August, so fixing the code alone wouldn't have made anything appear.

Once per league, the plugin now says which it is:

favorite team 'MUN' is not a Premier League team code. Closest match is
'MAN' (Manchester United). See TEAMS.md for the full list.

Premier League favorite teams MAN look correct, but the league has no fixtures
published yet — its season starts 21 August 2026. An empty display until then
is expected, not a configuration problem.

On the suggestions

String distance is useless at three characters: MUN scores identically against MAN and SUN, so Manchester United and Sunderland tie and the hint becomes a coin flip — my first attempt duly suggested Sunderland.

So suggestions match how people actually abbreviate: each part of the code must prefix a word of the club name, in order. MUN splits as M-anchester UN-ited, while Sunderland offers no word starting with M. Validated against all seven leagues' real data — MUNMAN, MCIMNC, MANUMAN, RMRMA, OMOLM, ASMMON — and a wrong-case code is told so explicitly, since matching is case-sensitive.

Safety

The check is purely diagnostic: once per league per process, never retried on failure, every error swallowed so it cannot disturb updates. It re-runs on config change so a corrected code gets confirmed rather than staying silent.

Testing

  • 22 new tests in test_favorite_team_diagnostics.py, including the exact reported case.
  • Harness: all 24 screens PASS.
  • Existing soccer tests (test_live_mode_targeting, test_non_favorite_live_duration, test_world_cup_flags) still pass.
  • check_module_collisions.py: OK across 41 plugins.

Note for reviewers

This matching pattern is shared with the other scoreboard plugins, so the same silent-failure mode exists there. nrl-scoreboard was already moved to matching on ESPN team ID rather than abbreviation (#189) for related reasons — worth considering more widely, since ESPN can change abbreviations on promotion and relegation. Happy to port the diagnostic to the other sports if useful.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

Summary by CodeRabbit

  • New Features

    • Added diagnostics for invalid favorite team codes, including suggested corrections.
    • Added messaging to clarify when valid teams have no fixtures because their season has not started.
    • Improved team-code support using current ESPN data across major leagues.
  • Documentation

    • Updated team abbreviation tables and guidance, including cross-league differences and troubleshooting information.
  • Bug Fixes

    • Corrected team abbreviations that could cause empty scoreboard displays.

…is empty

A user set their Premier League favourite to "MUN" for Manchester United,
straight out of TEAMS.md, and saw nothing. Two separate problems.

TEAMS.md was wrong. ESPN uses MAN for Manchester United and MNC for Manchester
City; the file said MUN and MCI. It was not isolated — Real Madrid was RM rather
than RMA, Ligue 1 had eight wrong codes including Lyon (LYON not OL) and
Marseille (OLM not OM), Bundesliga four, and several rosters were a season out of
date. Every table is now generated from ESPN's live team endpoints and verified
against them, cross-checked with the standings endpoint.

Worse than a typo: MUN is a real code — it is Bayern Munich. Favourites match by
abbreviation across every enabled league, so anyone following the old docs with
the Bundesliga also enabled would have quietly followed the wrong club. TEAMS.md
now lists the codes that mean different clubs in different leagues.

The second problem is that this failed silently. Matching is an exact string
comparison against ESPN's abbreviation with no aliasing, so a plausible code
matches nothing and the display just stays empty. Between seasons a *correct*
code produces exactly the same empty screen, and the logs could not tell them
apart. Once per league the plugin now reports which it is:

  favorite team 'MUN' is not a Premier League team code. Closest match is
  'MAN' (Manchester United). See TEAMS.md for the full list.

  Premier League favorite teams MAN look correct, but the league has no fixtures
  published yet — its season starts 21 August 2026. An empty display until then
  is expected, not a configuration problem.

Suggestions match how people abbreviate rather than by string distance, which is
useless at three characters: 'MUN' scores identically against 'MAN' and 'SUN', so
Manchester United and Sunderland tie and the hint is a coin flip. Requiring each
part of the code to prefix a word of the club name separates them — 'MUN' splits
as M-anchester UN-ited, while Sunderland offers no word starting with M. Verified
against all seven leagues' real data: MUN->MAN, MCI->MNC, MANU->MAN, RM->RMA,
OM->OLM, ASM->MON, and a wrong-case code is told so explicitly.

The check is diagnostic only: it runs once per league, is never retried on
failure, and any error is swallowed so it cannot disturb updates.

Harness passes at all 24 screens; existing soccer tests still pass.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 67e4747d-e280-49df-b4c3-a10eac8328ed

📥 Commits

Reviewing files that changed from the base of the PR and between 4d96dc2 and 78630f0.

📒 Files selected for processing (4)
  • plugins.json
  • plugins/soccer-scoreboard/CHANGELOG.md
  • plugins/soccer-scoreboard/manager.py
  • plugins/soccer-scoreboard/manifest.json
📝 Walkthrough

Walkthrough

The soccer scoreboard plugin now validates favorite-team abbreviations against ESPN data, reports invalid or not-yet-scheduled teams, documents refreshed league mappings, adds diagnostic tests, and releases version 2.4.0.

Changes

Soccer scoreboard release

Layer / File(s) Summary
Favorite-team diagnostics and ESPN team codes
plugins/soccer-scoreboard/manager.py, plugins/soccer-scoreboard/TEAMS.md
Favorite-team codes are checked once per league against ESPN data, with correction suggestions and season-start diagnostics. League tables and troubleshooting guidance now use ESPN-aligned codes.
Diagnostic test coverage
plugins/soccer-scoreboard/test_favorite_team_diagnostics.py
Tests cover invalid, valid, mixed, case-sensitive, missing-fixture, fetch-failure, repeated-check, and suggestion scenarios.
Version and release documentation
plugins.json, plugins/soccer-scoreboard/manifest.json, plugins/soccer-scoreboard/CHANGELOG.md
Repository and plugin metadata advance to version 2.4.0, with release notes describing the team-code and logging changes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ScoreboardPlugin
  participant LeagueManagers
  participant ESPN
  participant Logger
  ScoreboardPlugin->>LeagueManagers: read favorite_teams
  ScoreboardPlugin->>ESPN: fetch team codes and season data
  ESPN-->>ScoreboardPlugin: return mappings and fixture state
  ScoreboardPlugin->>Logger: log warnings or informational diagnostics
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main changes: correcting TEAMS.md codes and adding diagnostics for empty leagues.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/soccer-team-codes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 107 complexity

Metric Results
Complexity 107

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins.json`:
- Line 3: Update the manifest’s release metadata for version 2.4.0 so the
top-level last_updated value is 2026-07-29, ensuring update_registry.py
propagates the correct date instead of the soccer entry’s stale 2026-07-17
value.

In `@plugins/soccer-scoreboard/manager.py`:
- Around line 1413-1455: Update _fetch_league_teams and _fetch_season_start to
read and refresh their ESPN responses through self.cache_manager using
plugin-namespaced cache keys, rather than making uncached synchronous requests.
Schedule cache refreshes from update so diagnostics are populated asynchronously
and cannot delay scoreboard update threads, while preserving the existing
parsing and diagnostic behavior.

In `@plugins/soccer-scoreboard/TEAMS.md`:
- Line 18: Update the heading in TEAMS.md from H3 to H2 so it follows the
document title and preserves heading hierarchy and generated navigation.
- Around line 20-23: Update the favorites-matching explanation in TEAMS.md to
state that each manager’s favorite_teams comes from its own league
configuration. Clarify that abbreviation matching is scoped to configured
favorites per league, so enabling multiple leagues alone does not make an eng.1
favorite select a Bundesliga team; duplicate codes match across leagues only
when configured in each league.
🪄 Autofix (Beta)

✅ Autofix completed


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ed4dbcb0-9152-4724-a879-8fe49f88817f

📥 Commits

Reviewing files that changed from the base of the PR and between 8d33894 and 4d96dc2.

📒 Files selected for processing (6)
  • plugins.json
  • plugins/soccer-scoreboard/CHANGELOG.md
  • plugins/soccer-scoreboard/TEAMS.md
  • plugins/soccer-scoreboard/manager.py
  • plugins/soccer-scoreboard/manifest.json
  • plugins/soccer-scoreboard/test_favorite_team_diagnostics.py

Comment thread plugins.json Outdated
Comment on lines +1413 to +1455
def _fetch_league_teams(self, league_key: str) -> Dict[str, str]:
"""ESPN's {abbreviation: display name} for a league."""
import requests

url = ("https://site.api.espn.com/apis/site/v2/sports/soccer/"
"{}/teams".format(league_key))
payload = requests.get(url, timeout=15).json()
entries = payload['sports'][0]['leagues'][0]['teams']
return {
t['team']['abbreviation']: t['team']['displayName']
for t in entries if t.get('team', {}).get('abbreviation')
}

def _fetch_season_start(self, league_key: str) -> Optional[str]:
"""
First fixture date when a league has none scheduled yet, else None.

ESPN keeps publishing a league's calendar between seasons while its
scoreboard is empty, which is exactly the state that looks like a broken
config.
"""
import requests
from datetime import datetime, timezone

url = ("https://site.api.espn.com/apis/site/v2/sports/soccer/"
"{}/scoreboard".format(league_key))
payload = requests.get(url, timeout=15).json()

if payload.get('events'):
return None # fixtures exist; an empty screen is not about the season

calendar = (payload.get('leagues') or [{}])[0].get('calendar') or []
for entry in calendar:
raw = entry if isinstance(entry, str) else entry.get('startDate')
if not raw:
continue
try:
when = datetime.fromisoformat(raw.replace('Z', '+00:00'))
except ValueError:
continue
if when > datetime.now(timezone.utc):
return when.strftime('%d %B %Y')
return None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not block updates on uncached diagnostics.

These direct requests run synchronously before manager update threads start, so an ESPN stall can delay each enabled league by up to 30 seconds. Cache the responses through self.cache_manager with plugin-namespaced keys, and schedule refreshes so diagnostics cannot hold up scoreboard updates.

As per coding guidelines: “Fetch or refresh network data in update, never in display, and use the shared self.cache_manager for network-fetched data” and “Namespace cache keys with the plugin ID.”

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 1418-1418: Request-controlled URL passed to requests; validate against an allowlist to prevent SSRF.
Context: requests.get(url, timeout=15)
Note: [CWE-918] Server-Side Request Forgery (SSRF).

(ssrf-requests)


[warning] 1438-1438: Request-controlled URL passed to requests; validate against an allowlist to prevent SSRF.
Context: requests.get(url, timeout=15)
Note: [CWE-918] Server-Side Request Forgery (SSRF).

(ssrf-requests)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/soccer-scoreboard/manager.py` around lines 1413 - 1455, Update
_fetch_league_teams and _fetch_season_start to read and refresh their ESPN
responses through self.cache_manager using plugin-namespaced cache keys, rather
than making uncached synchronous requests. Schedule cache refreshes from update
so diagnostics are populated asynchronously and cannot delay scoreboard update
threads, while preserving the existing parsing and diagnostic behavior.

Source: Coding guidelines

Manchester City (not `MCI`), `RMA` for Real Madrid (not `RM`), and `LYON` /
`OLM` for Lyon and Marseille (not `OL` / `OM`).

### Codes that mean different clubs in different leagues

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use an H2 heading here.

### skips directly from the document title to H3, breaking heading hierarchy and generated navigation.

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 18-18: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/soccer-scoreboard/TEAMS.md` at line 18, Update the heading in
TEAMS.md from H3 to H2 so it follows the document title and preserves heading
hierarchy and generated navigation.

Source: Linters/SAST tools

Comment on lines +20 to +23
Favourites are matched by abbreviation across every league you have enabled, so
if two of your enabled leagues share a code you will match both. The one to
watch is `MUN`, which is **Bayern Munich** in the Bundesliga — not Manchester
United.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clarify that favorites are configured per league.

Each manager receives favorite_teams from its own league config. A code only matches multiple leagues when it is configured under each of those leagues; merely enabling both leagues does not make an eng.1 favorite select a Bundesliga team.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/soccer-scoreboard/TEAMS.md` around lines 20 - 23, Update the
favorites-matching explanation in TEAMS.md to state that each manager’s
favorite_teams comes from its own league configuration. Clarify that
abbreviation matching is scoped to configured favorites per league, so enabling
multiple leagues alone does not make an eng.1 favorite select a Bundesliga team;
duplicate codes match across leagues only when configured in each league.

…mes release

Both branches bumped soccer-scoreboard to 2.4.0 — main's is the UTC start-time
fix (#228). Rebased this work onto 2.5.0 so main's release notes are preserved
beneath it rather than replaced.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Note

Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it.

Fixes Applied Successfully

Fixed 4 file(s) based on 4 unresolved review comments.

Files modified:

  • plugins.json
  • plugins/soccer-scoreboard/TEAMS.md
  • plugins/soccer-scoreboard/manager.py
  • plugins/soccer-scoreboard/manifest.json

Commit: f7736d5d5d8c29b850ea259a114b21d14de38b2a

The changes have been pushed to the fix/soccer-team-codes branch.

Time taken: 4m 57s

@ChuckBuilds
ChuckBuilds merged commit 49b0fe2 into main Jul 31, 2026
4 checks passed
ChuckBuilds pushed a commit that referenced this pull request Aug 1, 2026
Review follow-ups on #239.

f1-scoreboard's manifest disagreed with itself: last_updated said
2026-07-28 while its newest versions[] entry for the same 1.7.1 said
2026-08-01. update_registry.py mirrors last_updated into the catalog, so
the stale date propagated there too. Set it from the release entry and
regenerated plugins.json.

The 08-shared-sports-code doc still showed `except ImportError` for the
base_odds_manager guard, but the code was narrowed to
ModuleNotFoundError with a name check in an earlier round -- so the doc
was teaching the pattern its own examples no longer use. A bare
ImportError also swallows failures raised *inside* a core module that is
present, silently loading the bundled copy and hiding a broken install.

clock-simple's font loader caught bare Exception; narrowed to OSError,
which is what FreeType raises for a missing, unreadable or malformed
face. All three committed goldens pass unchanged.

Left alone:
- The measurement fallback in clock-simple stays broad on purpose. It
  runs on the render path and deliberately degrades to draw_text's own
  centring; letting a measurement hiccup propagate would blank a clock.
- soccer-scoreboard's _check_favorite_teams blocking update() is real,
  but it arrived in #233 (49b0fe2) from main and is not this PR's code.
- Five other manifests carry the same date drift from earlier PRs;
  out of scope here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
ChuckBuilds added a commit that referenced this pull request Aug 2, 2026
… plugin fleet (#239)

* chore: remove dead files with zero importers

hockey-scoreboard/base_classes.py and scoreboard_renderer.py are stale
near-duplicates of hockey.py's live code; basketball_helpers.py is an
unused font-loading helper. Nothing imports any of them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* perf: cache per-render font loads in of-the-day, web-ui-info, on-air

- of-the-day: the classic-fallback fonts (used when the core lacks
  src.element_style) were reloaded from disk on every render; they are
  config-independent, so load once and reuse.
- web-ui-info: display() reloaded the 4x6 font on every call.
- on-air: the shrink-to-fit path reloaded the scaled TTF each frame for
  wide labels; now memoized by (path, size).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* docs: shared sports code lineage map, scroll-key semantics, 8-size harness matrix

- New 08-shared-sports-code.md: the three scoreboard lineages, per-module
  drift table, guarded convergence pattern, sunset rule for local copies,
  and the fix-all-lineage-members-in-one-PR rule (commit 8d33894 as the
  cautionary example).
- 03-advanced-features.md: document the three incompatible scroll_speed
  unit semantics (px/frame, px/second, inverted frames-per-step divisor).
- 07-testing-ci-and-registry.md: the harness default matrix is 8 sizes,
  not 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* feat: standardize font/size/color customization and x-advanced scroll keys

Accessibility rollout, additive only — default rendering is byte-identical
in every plugin (custom faces load only when config differs from the
schema default, so web-UI merged-defaults configs are unaffected):

- clock-simple: honor the customization font/font_size keys the schema
  already declared (code previously read only text_color); existing
  golden images pass unchanged.
- news: per-element headline_text/source_text font+size; source line
  color was previously hardcoded (150,150,150) and is now configurable.
- mqtt-notifications: message_text font face+size (single text style).
- countdown: name_font_family — the name line previously had per-element
  size/color but shared the value line's font face.
- tide-display: tide_text/label_text font+size+color, routed by the
  existing palette constants; chart colors untouched.
- youtube-stats: channel_name/subscriber_count/view_count font+size+
  color (plugin previously had zero styling config).
- x-advanced added to scroll_speed/scroll_delay fine-tuning keys in
  nrl, ufc, elections, stocks, leaderboard, news, odds-ticker,
  march-madness, text-display schemas (UI hint only).

text-display and on-air deliberately unchanged: their existing config
surface already covers font face, size, and colors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* feat: honor global target_fps in elections, stocks, nfl-draft, march-madness, text-display

Non-scoreboard half of the global-FPS rollout (canonical pattern from
ledmatrix-leaderboard): read global_config target_fps/scroll_target_fps,
probe set_target_fps with hasattr, clamp 30-200 with a frame_time_target
fallback for older ScrollHelper builds.

- elections, stocks: adopt the block (previously ignored the global FPS).
- nfl-draft: also set an explicit scroll_delay (pacing was previously
  left at the helper default and unconfigured).
- march-madness: plugin-level display_options.target_fps still wins,
  falls back to the global; added the missing older-core fallback branch.
- text-display: added the hasattr guard + fallback (its 240 ceiling was
  already clamped to 200 inside the core helper).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* test: harness fixtures for 8 plugins, goldens for 6; fix of-the-day 64px overflow

Deterministic test/harness.json for countdown, christmas-countdown,
7-segment-clock, text-display, static-image, web-ui-info, of-the-day,
on-air (config + freeze_time archetype; static-image renders a bundled
test-pattern PNG). Golden images committed for the six deterministic
renderers — every golden was generated twice and byte-compared before
committing. No goldens for web-ui-info (renders the host IP) or on-air
(active state is event-driven).

The of-the-day fixture immediately exposed a real bug: on 64px-wide
panels the title (PressStart2P@8) and its underline drew past the right
edge, and body lines ran past the bottom on 64x32. Titles now ellipsize
to the panel width, the underline is clamped inside the panel, and body
lines stop before the bottom edge. Wider panels render identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* chore: bump versions for all plugins changed so far

Sports scoreboards still receiving font-cache/odds/fps work (afl,
baseball, football, soccer, lacrosse, f1) will be bumped with those
changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix: address review findings on FPS sourcing, panel clamps, and fixtures

- elections/stocks/nfl-draft/march-madness: source the FPS target from
  the plugin config's 'global' section (the news/leaderboard convention)
  instead of a never-set attribute; elections also refreshes it in
  on_config_change.
- text-display: single _apply_target_fps helper used by init and
  on_config_change — the latter previously called set_target_fps
  unconditionally and would raise AttributeError on older cores; the
  stored value is now the effective 30-200 clamped rate.
- of-the-day: clamp title_x after the user layout offset; guard
  _fit_title against a panel too narrow for the ellipsis itself.
- clock-simple: clamp the combined time+AM/PM block to start on-panel
  when a user-selected font exceeds the width.
- youtube-stats: truncate the channel name by measured width and derive
  row height from the selected fonts when a custom font is set (identical
  layout at the monospace default); validate color length in _rgb.
- tide-display: same color-length validation; unfold the one-line
  try/except flagged by Ruff E701.
- countdown fixture: pin font/color schema defaults explicitly so a
  future default change cannot silently invalidate the goldens.

All affected plugins re-verified with the harness; clock-simple,
countdown, and of-the-day goldens pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix(text-display): resolve font_path against the core install, not just cwd

CI runs the safety harness from the workspace root rather than the core
checkout, so the plugin's relative font_path missed assets/fonts and fell
back to PIL's default font — mismatching the committed goldens (generated
with the real font). Add a resolution strategy that walks up from the
display manager's module location to find the core's assets, making font
loading cwd-independent. Goldens now pass from both working directories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* perf(football-scoreboard): cache the record font instead of reloading per frame

The scorebug draw paths (SportsUpcoming/SportsRecent in sports.py,
FootballLive in football.py) reloaded 4x6-font.ttf from disk on every
rendered frame when records/rankings are shown; game_renderer's
_draw_records_or_rankings did the same unconditionally. The face is now
cached once in _load_fonts (fonts['record']) with a lazy memo in the
renderer, using the accessor pattern ufc-scoreboard already ships.

Rendering is pixel-identical: the full adaptive-layout and score-
celebration golden suites (46 tests) pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* perf(hockey,lacrosse): cache record/shots fonts instead of reloading per frame

Same treatment as football-scoreboard: fonts['record'] and fonts['shots']
cached in _load_fonts, scorebug draw sites use the accessor pattern, and
game_renderer's fallback disk load is memoized (its detail-font primary
lookup — a real lineage difference from football — is preserved).

Also fixes a latent crash: hockey.py's shots font load had no try/except,
so a missing 4x6-font.ttf killed the live render instead of degrading.

Harness output is byte-identical to the pre-change baselines for both
plugins; offline test scripts (favorite-live-boost, non-favorite-live-
duration, timezone-resolution) all pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* chore: bump football (2.10.0) and lacrosse (1.6.0) for the font-caching change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* perf(afl,baseball,soccer): cache record/ranking font instead of reloading per frame

Completes the font-caching sweep started for football/hockey/lacrosse
(cf93709, 640c081). Same treatment across the three remaining
sports-scoreboard lineages:

- afl-scoreboard: sports.py had three separate uncached reload sites
  (SportsUpcoming/SportsRecent ranking overlays); game_renderer.py was
  already caching fonts["record"] in _load_fonts. Added the same cache
  to sports.py's _load_fonts and switched all three sites to the
  self.fonts.get("record") accessor.
- baseball-scoreboard: sports.py had two uncached reload sites; fixed
  the same way. game_renderer.py already reused fonts['detail'], no
  change needed there.
- soccer-scoreboard: sports.py had three uncached reload sites, and
  game_renderer.py's _draw_records_or_rankings reloaded unconditionally
  on every call (no fonts-dict entry at all) -- given the football
  game_renderer lazy-memo treatment (getattr(self, '_record_font', ...)).

f1-scoreboard checked and needs no change: its renderer loads all fonts
once in __init__ (f1_renderer.py:180), no per-frame reload pattern
exists there.

Verified pixel-identical: rendered afl/baseball/soccer live+recent+
upcoming at 128x64 with show_records and show_ranking forced on, before
and after the change (via git stash) -- byte-for-byte identical PNGs
in all cases. Full safety harness (all 8 sizes) passes for all three
plugins with no new warnings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* perf(basketball,nrl,ufc,baseball): finish the record-font caching sweep

Completes the lineage-wide font-caching change (football, hockey,
lacrosse, afl, soccer landed earlier):

- basketball: record + tournament-date fonts cached in _load_fonts;
  game_renderer memoizes its record font.
- nrl: record font cached; game_renderer memoized.
- ufc: upcoming/recent scorebug paths use the cached record font the
  plugin already loaded for its live layout.
- baseball: (name,size)-keyed memo in _load_custom_font_from_element_config
  plus a BDF native-size cache, collapsing the per-frame 10-rung font
  ladder walks in the traditional-scoreboard and at-bat screens into
  dict lookups; the record-font block moves after the config fallback so
  it is set on every path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* refactor(sports): prefer the core-shipped odds manager with a bundled fallback

The eight non-UFC scoreboards imported their local base_odds_manager copy
unconditionally. They now try src.base_odds_manager first (a functional
superset: adds cache_ttl support) and fall back to the bundled copy on
cores that don't ship it. Both branches are module-level, so they stay
collision-safe under the loader's bare-name isolation.

In afl/nrl/soccer/basketball manager.py the odds import sat inside the
combined BasePlugin guard, so a missing odds module would have nulled
BasePlugin (and in nrl's case NameError'd — its except branch never set
BaseOddsManager). It now has its own nested guard.

UFC keeps its local copy unconditionally: it is a genuine MMA fork
(athlete odds), not a drifted duplicate. Local copies stay bundled per
the sunset rule in docs/plugin-development/08-shared-sports-code.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix(countdown): load family fonts cwd-independently so CI goldens match

The safety harness on CI runs from the workspace root, not the core
checkout, so the core FontManager's cwd-relative assets/fonts scan came
up empty and resolve_font silently degraded to PIL's default face —
drifting every committed golden (the named failure in the last two
safety runs). The type of the degraded font gives no signal (current
Pillow's load_default() is itself a FreeTypeFont), so the miss is
detected via the manager's font catalog, and the family's real file is
then resolved against the core install the display manager was loaded
from (same strategy text-display already uses). Verified: goldens pass
from both the core root and a foreign cwd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* feat(sports): honor the global target_fps in every scoreboard's scroll mode

The ten scroll_display.py files paced frames only from scroll_delay, so
the global smooth-scrolling FPS setting (target_fps / scroll_target_fps)
silently never reached the sports scoreboards. ScrollDisplay and
ScrollDisplayManager now accept a trailing global_config kwarg (threaded
from every manager construction site), and _configure_scroll_helper
applies the canonical set_target_fps block with the clamped
frame_time_target fallback for older cores. When no global value is set,
pacing is unchanged. Also removes the dead _get_target_fps helpers in
afl/nrl/soccer whose value was computed but never applied, and bumps f1
to 1.8.0 (its only change in this PR).

Verified: harness green on all ten plugins (f1's committed goldens pass
unchanged); football adaptive/celebration and soccer celebration pytest
suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix: address review findings on measurement fallback and registry dates

- tide-display: when display-manager measurement fails, fall back to the
  drawing font's own getbbox metrics before the 4px/char estimate, so a
  custom face never draws wider than the width reported.
- text-display, countdown: log module-relative font-probe failures at
  debug instead of swallowing them silently.
- afl/baseball manifests carried a stale last_updated (2026-07-17) that
  the registry echoed; set to 2026-07-31 and teach update_registry.py to
  sync last_updated even when latest_version is unchanged, so catalog
  timestamps can no longer drift from manifests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* ci: don't echo unvalidated plugin ids in the harness failure summary

Plugin ids are derived from PR file paths, so in the invalid-id branch
the raw string is fork-controllable; echoing it into workflow commands
is needless exposure. Redact it and record a placeholder in the summary
instead — valid ids (the useful signal) are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix(ufc): enable the scoreboard managers; test: mock fixtures for six scoreboards

The UFC managers pass sport_key='ufc_scoreboard' and SportsCore looked up
config under f'{sport_key}_scoreboard' — the nonexistent
'ufc_scoreboard_scoreboard' — so mode_config was always empty,
is_enabled always False, and every UFC screen rendered blank. The lookup
now uses the key the adapter actually writes ('ufc_scoreboard'). Found
while building harness fixtures: no cache/mock/config contents could
make the managers render.

Adds deterministic harness fixtures (config + cached-schedule mock data
+ frozen clock) for baseball, basketball, football, hockey, lacrosse,
and soccer, so the safety harness renders real game cards for
recent/upcoming (and live where the plugin's live path reads the cache:
basketball, soccer, plus baseball via its test_mode passthrough) instead
of blank no-data screens. Where the live path is a direct network fetch
with no cache read (hockey, football, lacrosse), live is disabled in the
fixture with the reason documented in each harness.json. Verified: all
sizes PASS and two consecutive runs are byte-identical for every plugin.

UFC gets no fixture yet: its fighter-headshot loader has no negative
caching and no config toggle, so offline runs spend ~15s of retries per
headshot per render — needs a small plugin change first (follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix: address CodeRabbit review findings across scoreboards, tide, countdown

- nrl manager: remove a stray unconditional BaseOddsManager = None that
  overwrote a successful bundled-fallback import on older cores (critical
  review catch).
- soccer + afl sports.py: move the cached record-font block after the
  config/fallback branches so it is set on every path, matching the other
  seven copies.
- hockey manager: drop the duplicate ScrollDisplayManager construction;
  the first instance (which enable_scrolling reads) now persists instead
  of being silently replaced.
- all ten scroll_display.py: coerce target_fps to float before comparing
  so a malformed global config degrades to scroll_delay pacing instead of
  raising in __init__.
- tide-display: resolve custom fonts against the core install (cwd-
  independent, same strategy as text-display/countdown) and clamp the
  three label positions that could go negative or overflow with large
  custom fonts.
- baseball sports.py: narrow the BDF strike-size retry to OSError and
  cache the fallback-default font under the requested key so a
  misconfigured font stops hitting the disk per frame.
- countdown: log per-candidate font-load failures instead of silently
  continuing, and memoize the FontManager catalog-miss check that was
  statting the filesystem on every render.

The bare-name fallback module rename suggestion is deliberately not
applied — module-level bare imports are collision-safe under the core
loader's isolation rules (see the review reply and 08-shared-sports-code.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* docs: record how plugins read device-wide settings

target_fps had no copy to converge -- self.config is only the plugin's own
slice, so a device-wide value simply wasn't reachable, which is why the
scoreboards' getattr(self, 'global_config', ...) always saw {}.

Documents the core-side property added in ChuckBuilds/LEDMatrix#424: the
resolution order, reading it as getattr(...) so plugins still load on older
cores, that it is read-only (mutating the live config has bitten this repo
before), and that assignment still overrides it -- which news, stock-news,
ledmatrix-stocks, ledmatrix-elections, ledmatrix-leaderboard and nfl-draft
depend on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix: address CodeRabbit round-2 findings (import narrowing, docs, probe logging)

- The odds-manager convergence guards now catch ModuleNotFoundError and
  check exc.name, so only an absent core module triggers the bundled
  fallback; an import failure from inside src.base_odds_manager (missing
  dependency) surfaces instead of being masked. Applied to all eight
  sports.py guards and the four manager.py nested guards; verified both
  branches with meta-path simulations.
- 08-shared-sports-code.md: the global_config example now uses the
  getattr form the same section mandates.
- tide-display: the module-relative font probe logs failures at debug
  level instead of a blind except/pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* chore: reclassify the scoreboard releases as PATCH

CONTRIBUTING.md scopes MINOR to new features and schema additions. None of
these ten plugins gained either: the changes are font-load caching, the
odds-manager import switch, target_fps threading, and test fixtures. The only
schema edits (nrl, ufc) add `x-advanced` UI hints, not options. That makes
them PATCH.

Each new version is set one patch above **main's** current version rather
than above the branch's, which also fixes a collision: soccer had been bumped
to 2.5.0 here while main independently released its own 2.5.0, so two
different sets of changes shared a version number. Now 2.5.1.

plugins.json could not simply be regenerated -- update_registry.py refuses to
lower a version, so it skipped all ten and left the registry advertising the
old MINOR numbers. Reset the generated file to main's state and regenerated
from there, which the tool accepts as an increase. Verified afterwards that
every latest_version moves up and none moves down.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* fix(clock-simple): stop an oversized date clipping at both ends

The date and weekday draws passed no x, so draw_text auto-centred them with
(width - text_width) // 2 and no clamp. _fit_date returns its shortest
candidate even when that still overflows -- reachable now that the font is
user-configurable -- and a negative x clips the string at *both* ends, losing
the leading characters rather than just the tail.

Adds _centered_x, which clamps to 0 so overflow clips on the right only. It
returns None when measuring raises, deferring to draw_text's own centring
rather than guessing, mirroring _text_fits assuming a fit so a measurement
failure never hides content.

The time and AM/PM paths already clamp (max(0, ...) on time_x, and
max(0, min(...)) on ampm_x), so this closes the remaining case.

Defaults are unchanged: all three committed goldens still match byte for byte,
harness green at all 8 sizes. Verified the fix directly -- "Aug 1st" at 84px
on a 64px panel gives draw_text x=-10 vs _centered_x x=0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ

* docs: add the validated scroll-display adoption recipe

Core 3.2.0 ships src/common/sports_scroll.py, the orchestration half of
scroll_display.py. The content half (prepare_scroll_content,
_load_separator_icons) stays per-plugin permanently -- a survey of the
eight copies that share a shape found eight distinct bodies, because
each draws its own game card. Same method name, different job.

Documents the mechanical adoption once a plugin floors at 3.2.0, the
byte-comparison acceptance gate, and the two gotchas the hockey pilot
surfaced (the inherited os.path use in _load_separator_icons, and the
now-dead scroll_helper guards).

Measured on hockey-scoreboard: 691 -> 289 lines, all 16 harness renders
byte-for-byte identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

* fix: sync f1 release metadata, correct the doc's import example

Review follow-ups on #239.

f1-scoreboard's manifest disagreed with itself: last_updated said
2026-07-28 while its newest versions[] entry for the same 1.7.1 said
2026-08-01. update_registry.py mirrors last_updated into the catalog, so
the stale date propagated there too. Set it from the release entry and
regenerated plugins.json.

The 08-shared-sports-code doc still showed `except ImportError` for the
base_odds_manager guard, but the code was narrowed to
ModuleNotFoundError with a name check in an earlier round -- so the doc
was teaching the pattern its own examples no longer use. A bare
ImportError also swallows failures raised *inside* a core module that is
present, silently loading the bundled copy and hiding a broken install.

clock-simple's font loader caught bare Exception; narrowed to OSError,
which is what FreeType raises for a missing, unreadable or malformed
face. All three committed goldens pass unchanged.

Left alone:
- The measurement fallback in clock-simple stays broad on purpose. It
  runs on the render path and deliberately degrades to draw_text's own
  centring; letting a measurement hiccup propagate would blank a clock.
- soccer-scoreboard's _check_favorite_teams blocking update() is real,
  but it arrived in #233 (49b0fe2) from main and is not this PR's code.
- Five other manifests carry the same date drift from earlier PRs;
  out of scope here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants